[Spring] Resilience4j로 장애 전파 막기(타임아웃·서킷·격벽·레이트 리미터)

회복 탄력성이 필요한 이유

분산 시스템에서 한 서비스의 장애는 호출 체인을 따라 다른 서비스로 전파된다. 주문이 결제를 부르고 결제가 외부 결제 대행사를 부르는 구조에서, 가장 말단의 결제 대행사가 응답하지 않으면 그 지연이 호출 체인을 거슬러 올라온다. 문제는 결제 대행사 하나가 느려지는 게 아니라, 그 앞의 결제 서비스와 다시 그 앞의 주문 서비스까지 함께 느려진다는 점이다.

만약 동기 호출이라면 결제 서비스의 스레드는 외부 응답을 기다리며 대기하고, 결제를 동기 호출한 주문 서비스의 스레드도 같이 대기 상태에 놓인다. 톰캣(Tomcat) 워커 풀을 넘어선 동시 요청들이 스레드를 점유하면 이후 요청부터는 큐에 쌓이거나 거절된다. 결제와 무관한 조회 요청까지 함께 막힌다. 가장 말단의 한 서비스가 느려지면 호출 체인, 서비스가 마비될 수 있다.

회복 탄력성(Resilience)은 이런 장애가 번지지 않도록 각 호출 지점을 격리하고, 문제가 있는 호출을 빠르게 끊거나 우회하는 것이다. Resilience4j는 이 성질을 데코레이터(Decorator) 패턴으로 제공하는 라이브러리다. 메서드에 어노테이션 하나만 붙이면 원래 호출을 타임아웃·서킷·격벽 같은 보호막으로 감싼다.

패턴막는 문제적용 위치
TimeLimiter느린 다운스트림이 호출자 스레드를 오래 점유외부 호출 메서드
CircuitBreaker죽은 다운스트림에 매번 비싼 호출을 반복외부 호출 메서드
Bulkhead한 다운스트림의 지연이 다른 호출 자원까지 잠식외부 호출 메서드
Retry일시적 실패를 즉시 실패로 확정외부 호출 메서드
RateLimiter진입 트래픽이 다운스트림 용량을 초과진입 컨트롤러

다운스트림 호출을 격리하는 구조

주문 서비스 안에서 결제 호출과 재고 호출은 서로 다른 보호막 인스턴스를 쓴다. 결제 호출은 TimeLimiter와 CircuitBreaker로, 재고 호출은 Bulkhead와 CircuitBreaker로 감싼다. 인스턴스를 분리해 두면 한 다운스트림이 느려져도 그 자원 고갈이 다른 호출 경로로 넘어가지 않는다.

응답 시간 제한(TimeLimiter)

주문이 결제를 동기 호출하는 코드를 그대로 두면, 결제가 N초 지연될 때 주문 톰캣 스레드도 N초 동안 블로킹된다. 이 대기 시간의 상한을 강제하는 것이 TimeLimiter다.

TimeLimiter는 비동기 반환 타입(CompletableFuture, Mono)에 시간 상한을 걸어, 정해진 시간 안에 끝나지 않으면 TimeoutException을 던지는 데코레이터다. 메서드에 어노테이션만 붙이면 모든 호출 경로에 일관되게 적용되고, resilience4j_timelimiter_calls_seconds 같은 메트릭이 자동으로 노출되어 어떤 호출이 얼마나 자주 타임아웃으로 끊기는지 관측할 수 있다.

resilience4j:
  timelimiter:
    configs:
      default:
        timeoutDuration: 2s          # 호출 응답을 최대 2초까지 대기, 초과 시 TimeoutException
        cancelRunningFuture: true    # 타임아웃 시 진행 중 Future를 cancel() (WebClient 요청도 끊김)
    instances:
      paymentTl:
        baseConfig: default          # 결제 호출 전용 TimeLimiter

TimeLimiter는 CompletionStage 계열 반환 타입에만 동작하기 때문에, WebClient의 결과를 retrieve()로 응답 스펙을 받고 bodyToMono()로 바디를 뽑은 다음 toFuture()로 감싸 넘긴다.

@CircuitBreaker(name = "paymentCb", fallbackMethod = "fallback")
@TimeLimiter(name = "paymentTl")
fun pay(req: PaymentRequest): CompletableFuture<PaymentResponse> =
    webClient.post()
        .uri("/payments")
        .bodyValue(req)
        .retrieve()
        .bodyToMono(PaymentResponse::class.java)
        .toFuture()

TimeLimiter는 반환 타입이 CompletionStage 이어야 결과값을 받을 수 있다. 애스펙트가 시간 상한을 걸어야 할 대상(Future)을 찾지 못해 곧바로 예외를 던지는 구조이기 때문이다. 즉 동기 메서드에 어노테이션만 붙여두면 컴파일은 되지만 호출 시점에 실패한다.

2초 안에 끝나지 않으면 TimeoutException이 올라오고, 호출자 스레드는 즉시 풀려난다. 다만 다운스트림이 간헐적으로 느린 것이 아니라 완전히 죽어 있다면, 매 호출마다 2초씩 기다리는 것도 낭비다. 이 낭비를 없애는 것이 서킷 브레이커다.

서킷 브레이커(CircuitBreaker)

결제 서비스가 죽어 있다면 굳이 또 호출해서 2초를 더 낭비할 필요가 없다. 서킷 브레이커는 다운스트림의 상태를 최근 호출 결과로 감지해서, 일정 실패율을 넘으면 호출 자체를 차단하는 패턴이다.

resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowType: COUNT_BASED            # 윈도우 방식: 횟수 기준(COUNT_BASED) or 시간 기준(TIME_BASED)
        slidingWindowSize: 10                     # 실패율 계산 기준이 되는 최근 호출 윈도우 크기 (10건)
        minimumNumberOfCalls: 5                   # 윈도우에 최소 N건이 쌓여야 실패율 평가 시작
        failureRateThreshold: 50                  # 실패율(%) 임계치. 50% 이상 실패하면 OPEN으로 전이
        waitDurationInOpenState: 5s               # OPEN 상태로 머무는 시간. 5초 후 HALF_OPEN 시도
        permittedNumberOfCallsInHalfOpenState: 3  # HALF_OPEN에서 허용할 시험 호출 수
        automaticTransitionFromOpenToHalfOpenEnabled: true  # 시간 만료 시 자동으로 HALF_OPEN 전이
    instances:
      paymentCb:
        baseConfig: default                       # 결제 호출용
      inventoryCb:
        baseConfig: default                       # 재고 호출용

서킷 브레이커는 세 상태를 오간다. 각 상태와 전이 조건을 상태도로 보면 다음과 같다.

  • CLOSED: 모든 호출이 다운스트림으로 흐르고, 성공과 실패를 슬라이딩 윈도우(sliding window)에 기록한다. 정상 상태다.
  • OPEN: 최근 10건 중 50% 이상이 실패하면 전이된다. 이 상태에서는 다운스트림을 호출하지 않고 즉시 fallback을 반환한다. 죽은 서비스에 비싼 호출을 반복하지 않는다.
  • HALF_OPEN: OPEN 상태로 5초가 지나면 자동 전이된다. 제한된 수(3건)의 시험 호출만 흘려보내고, 회복됐으면 CLOSED로, 여전히 실패하면 다시 OPEN으로 보낸다.

slidingWindowType은 두 가지다. COUNT_BASED는 최근 N건의 호출로 실패율을 계산하고, TIME_BASED는 최근 N초의 호출로 계산한다. 트래픽이 적은 서비스에서는 호출 몇 건이 오래된 실패까지 끌고 오는 문제가 있어 TIME_BASED가 안정적이다.

서킷 브레이커는 다운스트림이 죽었을 때는 잘 막아주지만, 간헐적으로 느려지는 상황에서는 완전하지 못하다. 결제는 정상인데 재고만 6초씩 늘어지는 경우를 생각해보면, 서킷은 실패가 아니라 지연이라 잘 열리지 않는다. 이 경우는 다른 패턴이 필요하다.

동시 호출 격리(Bulkhead)

Bulkhead는 특정 호출이 쓸 수 있는 동시 실행 수를 제한해, 한 다운스트림에 점유된 자원이 다른 호출 경로의 자원까지 갉아먹지 않도록 격리한다.

재고 호출이 6초씩 늘어지는 상황을 다시 보면, WebClient의 커넥션 풀이 공유되어 있을 때 재고 호출이 풀의 커넥션을 전부 점유하면 결제 호출까지 새 커넥션을 못 잡는다. 동시 block() 호출이 몰리면 호출 스레드 풀 자체가 고갈된다. 한 다운스트림의 지연이 다른 다운스트림의 정상 호출까지 끌고 들어가는 것이다.

resilience4j:
  bulkhead:
    configs:
      default:
        maxConcurrentCalls: 10        # 동시에 진입 가능한 호출 수(세마포어 카운트)
        maxWaitDuration: 50ms         # 슬롯이 가득 찼을 때 대기할 최대 시간. 초과 시 BulkheadFullException
    instances:
      inventoryBh:
        baseConfig: default           # 재고 호출용 Bulkhead

재고 호출 메서드에 어노테이션을 붙이면, 이 메서드를 동시에 실행할 수 있는 수가 10개로 제한된다.

@Bulkhead(name = "inventoryBh", type = Bulkhead.Type.SEMAPHORE)
@CircuitBreaker(name = "inventoryCb", fallbackMethod = "fallback")
fun reserve(req: ReserveRequest): ReserveResponse =
    webClient.post()
        .uri("/inventory/reserve")
        .bodyValue(req)
        .retrieve()
        .bodyToMono(ReserveResponse::class.java)
        .block() ?: throw IllegalStateException("inventory empty body")

11번째 호출은 50ms만 대기하다 BulkheadFullException으로 떨어지고 fallback으로 간다. 결제 호출과 재고 호출이 서로 다른 Bulkhead 인스턴스를 쓰면, 재고 격실이 가득 차도 결제 호출 자원은 보호된다. 다만 이 BulkheadFullException도 예외인 이상, 바깥에 있는 CircuitBreaker(inventoryCb)의 슬라이딩 윈도우에는 실패로 함께 기록된다. 격벽이 계속 가득 차면 재고 호출의 서킷까지 열릴 수 있다는 뜻이다.

타입동작특징
SEMAPHORE세마포어 카운트만 증감, 호출은 호출자 스레드에서 실행가볍다. 별도 스레드 풀 없음
THREADPOOL별도 스레드 풀에서 실행하고 결과를 CompletableFuture로 반환TimeLimiter와 조합하기 좋다. 이미 논블로킹인 호출이면 SEMAPHORE로 충분하다

THREADPOOL 타입은 원래 메서드가 무엇을 반환하든 별도 스레드 풀에서 실행한 뒤 그 결과를 CompletableFuture로 감싸 돌려준다. TimeLimiter는 CompletionStage 위에서만 시간 상한을 걸 수 있으므로, 원래 블로킹으로 짜인 메서드를 THREADPOOL Bulkhead로 감싸면 그 위에 TimeLimiter를 사용할 수 있다.

재시도(Retry)

지금까지의 세 패턴은 실패를 빠르게 끊는 데 초점이 있었다. 반대로 Retry는 일시적 실패를 곧바로 실패로 확정하지 않고 몇 번 다시 시도한다. 네트워크 순간 단절이나 다운스트림의 짧은 순간 부하처럼, 잠깐 뒤에 다시 부르면 성공할 실패를 흡수한다.

resilience4j:
  retry:
    configs:
      default:
        maxAttempts: 3               # 최초 1회 + 재시도 2회, 총 3회 시도
        waitDuration: 200ms          # 재시도 사이 대기 시간
        enableExponentialBackoff: true  # 재시도마다 대기 시간을 배수로 늘림(200ms, 400ms, ...)
        retryExceptions:
          - java.io.IOException      # 이 예외에서만 재시도
    instances:
      paymentRetry:
        baseConfig: default

Retry는 재시도할 예외를 명시해서 쓴다. 모든 예외에 재시도를 걸면 비즈니스 검증 실패(잔액 부족 등)처럼 재시도해도 결과가 같은 요청까지 반복해 다운스트림 부하를 키운다. 그래서 네트워크 계열 예외에만 걸고, 재시도 간격은 고정보다 지수 백오프(exponential backoff)로 늘려 몰림을 줄인다.

재시도에서 반드시 확인할 조건은 멱등성(idempotency)이다. 결제 요청처럼 같은 요청을 두 번 보내면 두 번 청구될 수 있는 연산은, 재시도가 중복 청구로 이어진다. 이 경우 요청마다 고유한 멱등 키(idempotency key)를 실어 보내 다운스트림이 같은 키의 요청을 한 번만 처리하게 만든 뒤에 재시도를 켠다. 조회처럼 몇 번 불러도 결과가 같은 연산은 이런 준비 없이도 안전하다.

진입 트래픽 제어(RateLimiter)

앞의 네 패턴은 모두 다운스트림을 부르는 시점의 문제를 막는다. 반대로 자기 자신에게 트래픽이 몰리는 경우는 진입 지점에서 양을 통제한다. 주문 서비스가 들어오는 요청량을 제한하지 않으면 모든 트래픽이 그대로 결제·재고로 전달되고, 서킷이 열리기 전에 큐에 쌓인 요청이 전부 장애로 이어진다.

인스턴스 로컬 제어

RateLimiter는 토큰 버킷(token bucket) 알고리즘 기반의 패턴이다. 일정 주기마다 정해진 수의 토큰을 채워두고, 요청이 들어올 때마다 토큰을 하나씩 소모한다. 토큰이 없으면 호출을 차단한다.

resilience4j:
  ratelimiter:
    configs:
      default:
        limitForPeriod: 10           # 한 주기 동안 허용되는 호출 수 (버킷 최대 토큰 수)
        limitRefreshPeriod: 1s       # 토큰 리필 주기. 1초마다 limitForPeriod만큼 채움
        timeoutDuration: 0           # 토큰 없을 때 대기 시간. 0이면 즉시 RequestNotPermitted
    instances:
      orderRl:
        baseConfig: default          # 주문 진입점에 적용

주문 컨트롤러의 진입 메서드에 어노테이션을 붙이면 1초당 10건까지만 통과시키고, 그 이상은 RequestNotPermitted 예외를 던진다. 컨트롤러에 예외 처리기를 두어 이 예외를 HTTP 429(Too Many Requests)로 매핑한다.

@RateLimiter(name = "orderRl")
@PostMapping("/orders")
fun place(@RequestBody req: PlaceOrderRequest): OrderResponse =
    orderService.place(req)
 
@ExceptionHandler(RequestNotPermitted::class)
fun handleRateLimit(e: RequestNotPermitted): ResponseEntity<Any> =
    ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
        .body(mapOf("error" to "rate limit exceeded"))

이 RateLimiter는 인스턴스 로컬이다. 토큰을 JVM(Java Virtual Machine) 메모리에서 관리하기 때문에, 주문 서비스를 3대로 스케일아웃하면 실제 허용량은 1초당 30건이 된다. 결제·재고가 감당할 수 있는 총량은 그대로인데 진입 한계가 인스턴스 수에 비례해 늘어나는 셈이다.

클러스터 전체를 통제하는 글로벌 제어(Redis)

클러스터 전체의 허용량을 일정하게 유지하려면 카운터를 외부 저장소에 두어야 한다. 모든 인스턴스가 같은 카운터를 공유하면 인스턴스가 몇 대든 총 허용량이 고정된다. Redis의 INCREXPIRE는 원자적이고 응답이 1ms 미만이라, 요청마다 호출해도 부담이 크지 않다.

카운터는 1초 단위 버킷 키에 담고, INCR로 증가시킨다. 첫 증가일 때만 만료 시간을 건다.

local current = redis.call('INCR', KEYS[1])      -- 현재 윈도우 키의 카운터를 1 증가
if current == 1 then                             -- 첫 INCR이면 (방금 만들어진 키) TTL 설정
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current

키는 rl:orders:{epochSecond} 형태로 1초 단위 버킷이다. 윈도우가 바뀌면 키 자체가 달라지므로 카운터가 자연스럽게 리셋된다. 만료 시간(TTL)은 윈도우 길이보다 1초 길게 두어 지난 버킷이 자동 정리되게 한다.

INCREXPIRE를 각각 별도 명령으로 보내면 경합(race condition)이 생긴다. INCR 직후 EXPIRE 직전에 노드가 죽으면 만료 시간이 없는 키가 영원히 남는다. Lua 스크립트로 두 명령을 하나로 합치면 Redis 단일 스레드 모델 안에서 원자적으로 실행된다.

@Component
class RedisRateLimiter(
    private val redis: StringRedisTemplate,
    @Value("\${ratelimit.redis.limit-per-second:20}") private val limit: Long,
    @Value("\${ratelimit.redis.window-seconds:1}") private val windowSeconds: Long,
) {
    fun tryAcquire(name: String): Boolean {
        val bucket = System.currentTimeMillis() / (windowSeconds * 1000)
        val key = "rl:$name:$bucket"
        val count = redis.execute(luaScript, listOf(key), (windowSeconds + 1).toString())
        return count <= limit   // 카운터가 한도 이하인 동안만 허용
    }
}

진입 컨트롤러는 요청을 받자마자 tryAcquire를 호출하고, false면 즉시 429를 반환한다. 통과한 요청만 주문 로직으로 넘긴다.

@PostMapping("/orders/global")
fun place(@RequestBody req: PlaceOrderRequest): ResponseEntity<Any> {
    if (!redisRateLimiter.tryAcquire("orders")) {
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .body(mapOf("error" to "rate limit exceeded"))
    }
    return ResponseEntity.ok(orderService.place(req))
}
항목RateLimiter(Resilience4j)RedisRateLimiter
카운터 저장소JVM 메모리Redis
인스턴스 N대일 때 총 허용량limit 배수(limit × N)limit(고정)
호출 비용메모리 카운터(마이크로초)Redis 왕복(밀리초)
장애 시영향 없음Redis 다운 시 fail-open vs fail-closed 정책 결정 필요
적합한 곳단일 인스턴스 보호, 노이즈성 트래픽 차단클러스터 전체 한도, 외부 서비스 수준 협약(SLA) 준수

Redis 글로벌 RateLimiter는 그 자체로 Redis가 단일 장애 지점이 되므로, Redis가 다운됐을 때 요청을 전부 통과시킬지(fail-open) 전부 막을지(fail-closed)를 미리 정해둔다. 뒤에 CircuitBreaker나 Bulkhead 같은 다른 보호막이 이미 있는 호출이라면 fail-open이 안전에 가깝고, RateLimiter 자체가 유일한 방어선이라면 fail-closed가 안전하다.

여러 패턴을 함께 쓸 때의 순서

한 메서드에 여러 패턴을 함께 붙이면 Resilience4j가 정해진 순서로 데코레이터를 감싼다. 이 순서는 애노테이션을 코드에 쓴 순서와 무관하다. 각 애스펙트가 스프링 AOP 상에서 고정된 우선순위를 갖고 있어서, pay()@CircuitBreaker를 위에 쓰든 @TimeLimiter를 위에 쓰든 실제로 감싸는 순서는 바뀌지 않는다. 기본 순서는 다음과 같다.

순번데코레이터역할
1Retry실패 시 전체 체인을 처음부터 다시 시도한다(가장 바깥)
2CircuitBreaker결과(성공/실패)를 기록하고, OPEN이면 안쪽 호출 자체를 막는다
3RateLimiter토큰이 없으면 여기서 막는다
4TimeLimiter실행 시간 상한을 건다
5Bulkhead동시 실행 슬롯을 잡는다(실제 메서드에 가장 가깝다)

호출은 Retry에서 시작해 CircuitBreaker, RateLimiter, TimeLimiter를 차례로 거쳐 Bulkhead를 통과한 뒤에야 실제 메서드에 도달한다. 결과는 반대 순서로 반환된다. TimeLimiter가 Bulkhead보다 바깥에 있으므로, TimeLimiter의 시간 상한에는 Bulkhead가 슬롯을 기다리는 시간(maxWaitDuration)까지 포함된다는 점도 함께 기억해 둔다.

이 순서에서 CircuitBreaker는 TimeLimiter보다 바깥이다. 그런데 같은 메서드에 TimeLimiter와 CircuitBreaker를 함께 붙이면서 양쪽에 fallbackMethod를 두면, 안쪽에 있는 TimeLimiter가 타임아웃 예외를 먼저 흡수해버린다. 그러면 바깥의 CircuitBreaker는 모든 호출을 성공으로 보게 되어 실패율이 올라가지 않고, 서킷이 영영 OPEN으로 전이되지 않는다.

// 잘못된 패턴 → 안쪽 TimeLimiter가 예외를 먼저 삼켜 CircuitBreaker가 실패를 못 셈
@CircuitBreaker(name = "paymentCb", fallbackMethod = "fallback")
@TimeLimiter(name = "paymentTl", fallbackMethod = "fallback")
fun pay(req: PaymentRequest): CompletableFuture<PaymentResponse> = ...
 
// 올바른 패턴 → 바깥 CircuitBreaker에만 fallback을 둔다
@CircuitBreaker(name = "paymentCb", fallbackMethod = "fallback")
@TimeLimiter(name = "paymentTl")
fun pay(req: PaymentRequest): CompletableFuture<PaymentResponse> = ...

바깥쪽 CircuitBreaker에만 fallbackMethod를 두면, 타임아웃 예외가 위로 올라오면서 서킷이 실패로 카운트하고 그다음 fallback이 호출된다. fallback 자체도 아무 값이나 돌려주는 자리가 아니라, 사용자에게 의미 있는 대체 응답이어야 한다. 결제 타임아웃을 결제 확인 중 상태로 돌려주고 이후 비동기로 정산하는 식으로, 실패를 부분 성공으로 전환하는 설계가 좋은 fallback이다.

실제 사례에서의 적용

지금까지의 패턴이 실무의 어떤 장애를 막는지 두 가지 사례로 정리한다.

외부 결제 대행사 응답 지연

이커머스 결제 흐름에서 외부 결제 대행사 응답이 평소 200ms에서 갑자기 8초로 늘어졌다고 하자. 보호막이 없으면 이 지연이 결제 서비스를 거쳐 주문 서비스까지 거슬러 올라가, 결제와 무관한 주문 조회까지 함께 장애를 겪는다.

  • TimeLimiter는 대행사 호출의 대기 시간을 2초로 끊는다.
  • CircuitBreaker는 장애가 이어질 때 호출 자체를 차단한다.
  • Bulkhead는 대행사 호출과 쿠폰·포인트 같은 다른 외부 호출의 자원을 서로 분리한다.

이벤트로 인한 트래픽 급증

특가 이벤트 오픈 직후 분당 100건이던 주문 트래픽이 분당 5만 건으로 튀었다고 하자. 인스턴스마다 로컬 RateLimiter만 두면 총 허용량이 인스턴스 수에 비례해 늘어나, 결제 서비스가 감당할 수 있는 고정 용량을 그대로 넘어선다.

  • RateLimiter(인스턴스 로컬)는 인스턴스 하나에 몰리는 폭주를 빠르게 거른다.
  • RedisRateLimiter(글로벌)는 인스턴스 수와 무관하게 클러스터 전체 허용량을 고정한다.

결제 타임아웃 시나리오

결제 호출에 지연이 주입됐을 때 TimeLimiter와 CircuitBreaker가 함께 동작하는 순서를 시간 축으로 보면 다음과 같다. 여기서는 누가 언제 무엇을 호출하는지가 핵심이라 시퀀스로 표현한다.

sequenceDiagram
    autonumber
    participant C as Client
    participant OC as OrderController
    participant OS as OrderService
    participant IC as InventoryClient
    participant PC as PaymentClient
    participant Inv as inventory-service
    participant Pay as payment-service

    C->>OC: POST /orders
    OC->>OC: RateLimiter 토큰 획득
    OC->>OS: place(req)
    OS->>IC: reserve(...)
    IC->>Inv: POST /inventory/reserve
    Inv-->>IC: 200 RESERVED
    IC-->>OS: ReserveResponse
    OS->>PC: pay(...)
    PC->>Pay: POST /payments
    Note over Pay: 5초 지연 발생
    Note over PC: TimeLimiter 2초 만료로 TimeoutException
    PC->>PC: CircuitBreaker 실패 카운트 증가 후 fallback 호출
    PC-->>OS: PENDING 응답
    OS-->>OC: OrderResponse
    OC-->>C: 200 OK (paymentStatus=PENDING)

연속 실패가 누적되어 최근 10건 중 절반이 넘으면, 다음 호출부터 서킷이 OPEN으로 전이되어 payment-service로의 호출 자체가 일어나지 않고 즉시 fallback이 반환된다.

관측(Observability)

Resilience4j는 Micrometer와 연동되어 각 패턴의 상태와 호출 통계를 메트릭으로 노출한다. Spring Boot Actuator 엔드포인트를 열어두면 서킷 상태나 격실 점유를 외부에서 조회할 수 있다.

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus,circuitbreakers,ratelimiters,bulkheads,timelimiters
  health:
    circuitbreakers:
      enabled: true                   # 서킷 상태(OPEN 등)를 health에 반영
    ratelimiters:
      enabled: true
 
resilience4j:
  circuitbreaker:
    configs:
      default:
        registerHealthIndicator: true  # /actuator/health에 서킷 별도 지표 등록

이렇게 열어두면 /actuator/circuitbreakers로 각 서킷의 현재 상태(CLOSED, OPEN, HALF_OPEN)를 조회할 수 있고, /actuator/health에 서킷이 별도 지표로 나타난다. Prometheus로 수집하는 대표 메트릭은 다음과 같다.

메트릭의미
resilience4j_circuitbreaker_state서킷의 현재 상태(라벨로 상태 구분)
resilience4j_circuitbreaker_failure_rate슬라이딩 윈도우 실패율
resilience4j_timelimiter_calls_seconds타임아웃/성공 호출 수와 소요 시간
resilience4j_bulkhead_available_concurrent_calls격실에 남은 동시 실행 슬롯 수
resilience4j_ratelimiter_available_permissions현재 남은 토큰 수

이 메트릭을 대시보드로 띄우면, 서킷이 얼마나 자주 열리는지와 타임아웃이 어느 호출에 몰리는지를 눈으로 확인할 수 있다. 이 글에서 쓴 실패율 50%, 대기 5초, 동시 10 같은 값은 출발점일 뿐이다. 실제 임계치는 다운스트림의 P95 응답 시간과 처리량을 관측 값으로 확인한 뒤 조정한다.

Resilience4j 패턴은 모두 한 곳의 장애가 다른 곳으로 번지지 않게 격리한다. 다운스트림 호출 시점의 문제는 TimeLimiter·CircuitBreaker·Bulkhead·Retry로, 진입 트래픽의 양적 통제는 RateLimiter로, 분산 환경의 일관성은 Redis 글로벌 카운터로 막는다. 각 패턴이 무엇을 막는지 이해하고 다운스트림의 실제 지표로 임계치를 정하면, 장애 전파를 끊고 회복 탄력성을 높일 수 있다.